Skip to content

Fix: three Phase 10 defects and correct the Phase 10 documents - #59

Merged
Wahbeh-Mohammad merged 2 commits into
mvpfrom
25-phase-10-deviation-reconciliation
Aug 30, 2026
Merged

Fix: three Phase 10 defects and correct the Phase 10 documents#59
Wahbeh-Mohammad merged 2 commits into
mvpfrom
25-phase-10-deviation-reconciliation

Conversation

@Wahbeh-Mohammad

Copy link
Copy Markdown
Contributor

What changed

Defects fixed

  • scripts/verify-dual-consumption.mjs read Symbol.asyncDispose without a
    guard. The symbol is absent on Node 20.3.0, which is the declared floor. This
    blocking gate failed on that floor. The script now uses a guard.
  • UndiciTransport.close() stopped at the first dispatcher that failed to
    destroy. It leaked the other dispatchers. With a proxy, it leaked the
    ProxyAgent that holds the pooled connections. The method now destroys all
    dispatchers. It reports failures as TransportFailureError.
  • UndiciTransport.send() prepared the body before it mapped the headers.
    Header mapping reads a caller-supplied getter. If that getter throws, an
    active body producer stays behind. The method now maps the headers first.
  • One disposal test was weak. It passed against a dispose method that did
    nothing. The test now asserts that dispose destroys the dispatcher.

Gates

  • packages/shrink-test/ asserts that the disposal symbol stays after esbuild
    removes unused code. This keeps "sideEffects": false correct.
  • The NFR-12 gate now compares the digests of 9 npm pack tarballs. Before,
    only dist/ was compared.
  • The ci-preflight skill now lists the reproducible-build gate.

Documents

  • The roadmap and the phase design said Phase 10 ships no code. Phase 10 shipped
    code. Both documents now record the change of scope.
  • The two deviation ledgers now refer to each other.
  • The knowledge corpus is marked stale. Its pin is three revisions old.
  • The Node floor stays at >=20.3. NFR-10 does not permit a higher floor for
    the core. This decision is now recorded.

Reviews performed

Three passes. Each pass used a separate subagent.

  1. Shape — the diff against the requirement. Result: the shape is correct.
    The guarded install satisfies NFR-10.
  2. Adversarial — the running code. Result: three defects. Each defect has a
    test. Each test failed before the fix.
  3. Reader — the complete files. Result: stale comments, one weak test, and
    documents that did not match the code.

Verification: preflight passes 15 of 15 steps with --clean, and 16 of 16 with
--node-floor. The floor leg runs on Node 20.3.0. 2175 tests pass.

Open items and deferred items

  • NFR-16 — the release workflow with --provenance. Not done. The trigger,
    the tag convention, and the publish permissions are project decisions.
  • F2, F7, and CONSTANT_CASE — three project-wide convention sweeps. The
    roadmap listed Phase 10 as the owner. Phase 10 is the last row of the phase
    table, so no later phase can own them. All three are now marked as not
    scheduled, each with a trigger.
  • Knowledge corpusdocs/knowledge/deliberate-deviations.md needs a new
    harvest. A harvest is out of scope for this branch.
  • bun run test:scripts — not part of CI. See open-items H13.

…bug it found

Audits all 17 entries of the Phase 10 reconciled ledger
(docs/sdk-design-nodejs/10-deliberate-deviations-from-the-reference-contract.md)
against the as-built code rather than against the phase specs that produced
it. Fourteen hold and are permanently uncorrectable; three did not survive
contact with the source. One of those three was a live defect.

Fixes the defect:

- Page, FetchTransport, and UndiciTransport each declared
  [Symbol.asyncDispose] as a plain computed class member. The symbol arrived
  in Node 20.4; every package declares engines.node ">=20.3". On the declared
  floor the computed key evaluates to `undefined`, so the method bound to the
  string key "undefined" — junk on the prototype, no working disposal, and a
  .d.ts promising AsyncDisposable regardless. Verified against the built
  artifact on real Node 20.3.0:

      before →  Page.prototype keys: [ 'constructor', 'undefined' ]
      after  →  Page.prototype keys: [ 'constructor', 'close' ]

  All three now install it via Object.defineProperty behind
  `typeof Symbol.asyncDispose === m. Phase 3b had
  already found this hazard and pinned Response's freedom from it
  (http/response.test.ts); 6c and classes.

- test/node-conformance/pagination
  `typeof page[Symbol.asyncDispose] === 'function'`. On the floor that reads
  page['undefined'], which *was* ttion passed over
  a Page that could not be disposed — the 20.3.0 matrix leg reported
  conformance it did not have. It nd asserts the
  junk key's absence on both legs: 20.3.0 → 3 pass / 1 skip, current → 4 pass.

Breaking, in the type system only (changeset included; pre-1.0 minor):
Page no longer declares `implementhTransport() /
undiciTransport() return `Transport` rather than `Transport & AsyncDisposable`.
`await using` therefore stops types. Deliberate:
the declaration was only ever true on 20.4+, and on the floor it type-checked
a call that silently did nothing —ant leaking every
pooled connection. close() is the supported teardown path and is unchanged.

Closes NFR-12 on evidence:

- Two clean builds of an identical tree (every dist/ and *.tsbuildinfo swept
  between them) emit 644 byte-idendexpace/core
  twice yields an identical tarball digest.
- scripts/verify-reproducible-builCI step, and is
  registered in ci-preflight's hardcoded STEPS list too — otherwise the
  preflight silently under-covers ci.yml. Negative-tested by injecting a
  Date.now() into gen-version.mjs: the gate fails naming
  dist/generated/version.js.
- The ledger's "cannot execute without a real build artifact" premise expired
  once Phases 1-9 shipped code. This needed *code*, not a *publish*, and sat
  open two phases longer than it had to (open-items.md B3, now closed).

Corrects four ledger entries that misdescribed the code, each with an explicit
"corrected 2026-08-29" note rather than a silent overwrite:

  item 4   "never bare structural interfaces" — core.api.md exports 61
           interfaces against 58 classes, and Configuration is builder-built,
           frozen, and exported structurally. Narrowed to the http/ wire models
  item 7   "three tiers, not four" — the code implements CFG-1's full
           override → env → property → default chain, with CFG-3 key
           normalization; withPropertySource()/getRawProperty() are public API.
           Only the default production binding of the property layer is empty
  item 11  claimed Body/Response add the member (they never have — two tests
           assert the opposite), claimed all sites were guarded (only SseStream
           was), and omitted both transports while asserting consistency
           "across all four sites"
  item 14  claimed `npm publish --provenance` "is scripted (Phase 0 Task 3)".
           It is not, anywhere. NFR-12 split out and closed; NFR-16 stays open

Adds docs/deviations.md: the 14 uncorrectable deviations, each with the
file:line proving the claim and why the reference mechanism cannot be restored.

Not done here, deliberately: the NFR-16 release workflow. Authoring it with
--provenance and id-token: write is actionable now — only running it against a
real registry is blocked, so the ledger's "blocked until first release" framing
was half wrong and is corrected. It is left out because a release workflow's
trigger, environment, tag convention, and publish permissions are a project
decision, not a defect repair. Recorded as actionable-now in open-items.md,
alongside a WATCH item: raising engines.node to >=20.4 would let all four
disposal sites drop the guard and restore `await using` honestly.
…install

A three-pass review (shape, adversarial, reader) over 27fb81f. The shape
held: the guarded installs are NFR-10's repair, not a workaround, and the
Node floor stays >=20.3. Reading the running code rather than the diff
turned up three more live defects, all on the same seam.

- scripts/verify-dual-consumption.mjs asserted
  `typeof transport[Symbol.asyncDispose] === 'function'` unguarded, inside a
  blocking CI gate. On the >=20.3 floor that reads transport['undefined'] —
  which, before 27fb81f, resolved to the junk prototype entry the bug itself
  created. The gate's greenness depended on the defect it existed to catch;
  fixing the defect broke the gate, and CI could not see it because the `ci`
  job runs setup-bun's default node. Reproduced on real Node 20.3.0:

      AssertionError: 'undefined' !== 'function'   (exit 1)

  Now guarded, and asserts the junk key's absence on both legs.

- UndiciTransport.close() walked `owned` with a bare `for … await`, so the
  first rejecting destroy() abandoned the rest. The set is walked reversed,
  so with a proxy configured the leaked dispatcher is the ProxyAgent holding
  the pooled connections — a teardown path that leaked exactly what it was
  called to release. The raw undici error also escaped a public method
  untyped. Every dispatcher is now destroyed before anything throws, and
  failures surface as TransportFailureError with {cause}, AggregateError
  when several fail. close() memoizes the rejection, so TRANSPORT-16's
  "stable state" holds: the same error object comes back every time.

- UndiciTransport.send() called prepareBody() before toUndiciHeaders(), and
  header mapping reads request.body.mediaType — a getter on a caller-supplied
  Body that can throw. pumpBody starts its producer eagerly, so the throw
  escaped with a live producer nothing could abandon and a rejection headed
  for Node's default unhandledRejection policy: the SEAM-30 hazard
  body-pump.ts documents. Header mapping is hoisted above body preparation.
  FetchTransport was already correct by object-literal evaluation order, which
  is implicit and easy to regress, so it gets the same guard test.

- The undici disposal test asserted only that the member existed; an
  asyncDispose wired to a bare resolved promise passed it. It now swaps
  undici's Agent binding for a capturing subclass and asserts the owned
  dispatcher is destroyed by dispose alone.

Closes the sideEffects question 27fb81f opened. Four packages now carry a
module-scope Object.defineProperty install while declaring "sideEffects":
false. packages/shrink-test/ asserts the symbol survives a real esbuild
bundle + minify + treeShaking pass, negative-tested by stripping the install
from the built page.js. sideEffects was deliberately not narrowed to four
paths — a narrow list goes stale on the next file move and fails open.
Widens the NFR-12 gate to the leg 27fb81f verified by hand. npm pack is
deterministic here (npm normalizes tar entries rather than stamping wall-clock
time), so all 9 publishable packages are packed and digest-compared on both
clean builds. Injecting Date.now() into gen-version.mjs now fails both legs,
naming dist/generated/version.js and npm-pack:dexpace-core-0.0.0.tgz. The
gate's registration was also one place short: ci-preflight's SKILL.md
playbook table had no row for it, and five step counts across that skill were
stale.

Makes the doc tree describe what Phase 10 became:

  roadmap :65, design :15   asserted "review only" / "ships no package".
                            It shipped a breaking type change across three
                            packages, two changesets, a blocking CI step, and
                            four defect fixes — the same assert-without-
                            checking this phase spent 86 lines correcting
  §10 ↔ docs/deviations.md  two ledgers on one numbering scheme with no
                            cross-reference, and a third file with a nearly
                            identical name. All three now disambiguated
  knowledge corpus          deliberate-deviations.md pins sha f9ecb6e7d87b,
                            traced to 05d649a — three revisions back. Flagged
                            at the head, and inline on the two entries that
                            are substantively false, so the correction reaches
                            `bun run knowledge` output and not just a reader
  roadmap :169              maxRetries was already fixed in cba4721; the row
                            claiming Phase 10 owned it is marked resolved
  F2, F7, CONSTANT_CASE     project-wide convention sweeps, recorded Not
                            scheduled with triggers. Phase 10 is the last
                            roadmap row, so there was no honest owner to name

Records the floor decision where it will be found. Raising engines.node to
>=20.4 to restore `implements AsyncDisposable` was recorded as a pending
unblock; it is now decided against, in open-items.md and the two other places
the recommendation had spread to. NFR-10 requires a higher-floor capability to
be isolated into its own unit rather than lifting the general-purpose core's
floor, and requires the emitted target and visible-API level to agree — the
clause the unguarded member violated. >=20.3 is derived, not chosen: the
lowest Node that runs what these packages emit, set by globalThis.crypto
(absent from ESM on every Node 18) and AbortSignal.any() (20.3.0). Phase 4b
already decided this shape once, shipping a guarded suppress() rather than
moving the floor for SuppressedError.

Verified: preflight 15/15 --clean and 16/16 --node-floor on the pinned Bun
1.3.14, including the real Node 20.3.0 leg; 2175 tests.
@Wahbeh-Mohammad Wahbeh-Mohammad changed the title Fix three Phase 10 defects and correct the Phase 10 documents Fix: three Phase 10 defects and correct the Phase 10 documents Aug 30, 2026
@Wahbeh-Mohammad
Wahbeh-Mohammad merged commit 36c3f96 into mvp Aug 30, 2026
3 checks passed
@Wahbeh-Mohammad
Wahbeh-Mohammad deleted the 25-phase-10-deviation-reconciliation branch August 30, 2026 20:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant